<?php if (!defined('CORE_DIR')) die('Wrong entry point!');

/* 
 * Copyright 2010 Anton Muraviev a.k.a kuromaisu
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 * 
 *     http://www.apache.org/licenses/LICENSE-2.0
 * 
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * 
 */


// =============================================================================
// Section: Behavior Specification
// =============================================================================

Aero::define('AeroSpec', AS_STATIC);

/* -----------------------------------------------------------------------------
 * Class: AeroSpec
 * -----------------------------------------------------------------------------
 *
 */
abstract class AeroSpec
{
	private static $name = 'Specification';
	private static $specs = array();
	private $specified = false;
	private $expected_exception = null;
	private $expected_error = null;
	private $had_error = false;
	private $did_throw = false;
	
	/*
	 * Constructor: __construct
	 */
	public function __construct()
	{
		self::$specs[] = $this;
	}
	
	/*
	 * Method: setName
	 * 
	 * Sets specification name.
	 */
	public static function setName($name)
	{
		self::$name = $name;
	}
	
	/*
	 * Method: handleErrors
	 * 
	 * Error handler callback. Should never be called manually!
	 */
	public function handleErrors($errno, $errstr, $errfile, $errline)
	{
		$errstr = strip_tags($errstr);
		
		if (strpos($errstr, $this->expected_error) === false)
		{
			if (!is_null($this->expected_error))
			{
				throw new AeroSpecFailed('Unexpected error has occured.',$this->expected_error,$errstr,3,'Expected to contain','Actual error message');
			}
			else
			{
				throw new AeroSpecFailed('Unexpected error has occured at <kbd>'.$errfile.':'.$errline.'</kbd>.',$errstr,null,2,'Error message');
			}
		}
		
		$this->had_error = true;
	}
	
	/*
	 * Method: report
	 * 
	 * Produces the report sheet for all loaded specifications.
	 */
	public static function report()
	{
		$behaviors_total = $behaviors_failed = $behaviors_skipped = 0;
		$exceptions = array();
		
		foreach (self::$specs as $spec)
		{
			$spec_failed = false;
			$spec_class = get_class($spec);
			$spec_name = self::_humanize($spec_class);
			
			if (substr($spec_name, strlen($spec_name) - 14, 14)  === ' specification')
			{
				$spec_name = substr($spec_name,0,strlen($spec_name) - 14);
			}
			
			// Handle any errors which would occur inside spec
			set_error_handler(array($spec,'handleErrors'),E_ALL);
			
			// Try initializing specification
			try {
				$behaviors = $spec->getBehaviors();
				
				if (count($behaviors) === 0)
				{
					throw new AeroSpecSkipped('No behaviors are specified.');
				}
				
				$spec->initialize();
			} catch (AeroSpecSkipped $e) {
				if (isset($behaviors))
				{
					$behaviors_total+= count($behaviors);
					$behaviors_skipped+= count($behaviors);
				}
				
				$exceptions[$spec_class] = array(
					'specification' => $spec_name,
					'skipped' => isset($behaviors) ? count($behaviors) : '',
					'exception' => $e
				);
				$spec_failed = true;
			} catch (AeroSpecFailed $e) {
				$behaviors_total+= count($behaviors);
				$behaviors_failed+= count($behaviors);
				$exceptions[$spec_class] = array(
					'specification' => $spec_name,
					'failed' => count($behaviors),
					'exception' => $e
				);
				$spec_failed = true;
			}
			
			// Restore error handler for spec
			restore_error_handler();
			
			if ($spec_failed)
			{
				continue;
			}
			
			$exception_template = array(
				'specification' => $spec_name,
				'skipped' => array(),
				'failed' => array()
			);
			
			foreach ($behaviors as $behavior => $behavior_name)
			{
				$behaviors_total++;
				
				// Handle any errors which can occur inside behaviors
				set_error_handler(array($spec,'handleErrors'),E_ALL);
				
				// Prepare for behavior testing
				try {
					$spec->did_throw = false;
					$spec->had_error = false;
					// Test behavior
					$spec->beforeEach();
					$spec->specified = false;
					$spec->expected_error = null;
					$spec->expected_exception = null;
					
					// Try capturing any errors or exceptions
					try {
						$spec->checkBehavior($behavior);
					} catch (Exception $e) {
						// Rethrow AeroSpec exceptions
						if ($e instanceof AeroSpecSkipped || $e instanceof AeroSpecFailed)
						{
							throw $e;
						}
						
						if (get_class($e) !== $spec->expected_exception)
						{
							$message = 'Unexpected <var>'.get_class($e).'</var> was thrown with message: "'.
								$e->getMessage().'"'.
								(is_null($spec->expected_exception) ? 
									'' : ' Expected <var>'.$spec->expected_exception.'</var>.'
								);
							throw new AeroSpecFailed($message,$e->getTraceAsString(),null,2,'Exception');
						}
						else
						{
							$spec->did_throw = true;
						}
					}
					
					// Was an exception expected?
					if (!is_null($spec->expected_exception) && $spec->did_throw == false)
					{
						throw new AeroSpecFailed('<var>'.$spec->expected_exception.'</var> has not been thrown.');
					}
					
					// Was an error expected?
					if (!is_null($spec->expected_error) && $spec->had_error === false)
					{
						throw new AeroSpecFailed('Expected error has not occurred.',$spec->expected_error,null,2,'Expected');
					}
					
					if (false === $spec->specified)
					{
						throw new AeroSpecSkipped('Behavior does not seem to be specified.');
					}
					
				} catch (AeroSpecSkipped $e) {
					$behaviors_skipped++;
					array_push($exception_template['skipped'], array(
						'behavior' => $behavior_name,
						'exception' => $e
					));
					$exceptions[$spec_class] = $exception_template;
				} catch (AeroSpecFailed $e) {
					$behaviors_failed++;
					array_push($exception_template['failed'], array(
						'behavior' => $behavior_name,
						'exception' => $e
					));
					$exceptions[$spec_class] = $exception_template;
				}
				
				// Restore error handler
				restore_error_handler();
				
				// Clean after behavior testing
				$spec->afterEach();
			}
			
			
			$spec->uninitialize();
		}
		
		Aero::run('libs/specification/report',array(
			'test_name' => self::$name,
			'behaviors_total' => $behaviors_total,
			'behaviors_failed' => $behaviors_failed,
			'behaviors_skipped' => $behaviors_skipped,
			'exceptions' => $exceptions
		));
	}
	
	protected function checkBehavior(&$behavior)
	{
		$this->$behavior();
	}
	
	/*
	 * Method: getBehaviors
	 * 
	 * Returns:
	 * 	An associative array of behavior methods and names.
	 */
	protected function getBehaviors()
	{
		$behaviors = array();
		$methods = get_class_methods($this);
		
		foreach ($methods as $method)
		{
			if (strpos($method, 'itShould') === 0)
			{
				$behaviors[$method] = str_replace('it should ', '', self::_humanize($method));
			}
			else if (strpos($method, 'it_should') === 0)
			{
				$behaviors[$method] = str_replace('it should ', '', str_replace('_',' ',$method));
			}
		}
		
		return $behaviors;
	}

	/*
	 * Method: _humanize
	 * 
	 * Converts camelCased string to human string.
	 * 
	 * FIXME: this general method does not belong here.
	 */
	protected static function _humanize($string)
	{
		$string = preg_replace('~([A-Z])~', ' $1', $string);
		$string = strtolower($string);
		
		// Uppercase abbreviations 
		$string = preg_replace('~\b(([a-z] ){2,}([a-z]$)?)~e', "strtoupper(str_replace(' ','','\\1')).' '", $string);
		
		return trim($string);
	}
	
	// =======================
	// = Group: Flow control =
	// =======================

	/*
	 * Method: initialize
	 *
	 * Called before any behavior test.
	 */
	protected function initialize() {}
	
	/*
	 * Method: dest
	 *
	 * Called after all behavior tests have been complete.
	 */
	protected function uninitialize() {}
	
	/*
	 * Method: beforeEach
	 *
	 * Called before each behavior test.
	 */
	protected function beforeEach() {}
	
	/*
	 * Method: afterEach
	 * 
	 * Called before each behavior test.
	 */
	protected function afterEach() {}
	
	/*
	 * Method: skip
	 * 
	 * When called skips this test case.
	 * 
	 * Parameters:
	 * 	string	-	(optional) A reason why this case was skipped.
	 */
	protected function skip($reason)
	{
		throw new AeroSpecSkipped($reason);
	}
	
	// =====================
	// = Group: Assertions =
	// =====================
	
	/*
	 * Method: value
	 * 
	 * Returns:
	 * 	Wrapper for value to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecVariable>
	 */
	protected function value($value)
	{
		$this->specified = true;
		
		return new AeroSpecVariable($value,false);
	}
	
	/*
	 * Method: variable
	 * 
	 * Returns:
	 * 	Wrapper for variable to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecVariable>
	 */
	protected function variable(&$variable)
	{
		$this->specified = true;
		
		return new AeroSpecVariable($variable);
	}
	
	/*
	 * Method: object
	 * 
	 * Returns:
	 * 	Wrapper for object to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecObject>
	 */
	protected function object(&$object)
	{
		$this->specified = true;
		
		if (!is_object($object))
		{
			throw new AeroSpecFailed('This variable is not an object.', $object, null, 2);
		}
		
		return new AeroSpecObject($object);
	}

	/*
	 * Method: vector
	 * 
	 * Returns:
	 * 	Wrapper for vector (indexed array) to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecVector>
	 */
	protected function vector(&$array)
	{
		$this->specified = true;
		$vector = array_values($array);
		
		if ($array !== $vector)
		{
			throw new AeroSpecFailed('This variable is not a vector.', $array, null, 2);
		}
		
		return new AeroSpecVector($array);
	}

	/*
	 * Method: dictionary
	 * 
	 * Returns:
	 * 	Wrapper for dictionary (associative array) to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecDictionary>
	 */
	protected function dictionary(&$array)
	{
		$this->specified = true;
		$vector = array_values($array);
		
		if ($array === $vector)
		{
			throw new AeroSpecFailed('This variable is not a dictionary.', $array, null, 2);
		}
		
		return new AeroSpecDictionary($array);
	}

	/*
	 * Method: string
	 * 
	 * Returns:
	 * 	Wrapper for string to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecString>
	 */
	protected function string(&$string)
	{
		$this->specified = true;
		
		return new AeroSpecString($string);
	}

	/*
	 * Method: page
	 * 
	 * Returns:
	 * 	Wrapper for an external HTML document to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecString>
	 */
	protected function page($url)
	{
		$this->specified = true;
		
		return new AeroSpecHTML($url, true);
	}

	/*
	 * Method: html
	 * 
	 * Returns:
	 * 	Wrapper for an HTML document to make assertions about.
	 * 
	 * See Also:
	 * 	<AeroSpecString>
	 */
	protected function html($html)
	{
		$this->specified = true;
		
		return new AeroSpecHTML($html, false);
	}

	/*
	 * Method: isTrue
	 * 
	 * Throws:
	 * 	AeroSpecFailed	-	if condition is not TRUE
	 */
	protected function isTrue($condition)
	{
		$this->specified = true;
		
		if ($condition !== true)
		{
			throw new AeroSpecFailed('The condition is not <var>true</var>.');
		}
	}

	/*
	 * Method: isFalse
	 * 
	 * Throws:
	 * 	AeroSpecFailed	-	if condition is not FALSE
	 */
	protected function isFalse($condition)
	{
		$this->specified = true;
		
		if ($condition !== false)
		{
			throw new AeroSpecFailed('This condition is not <var>false</var>.');
		}
	}
	
	/*
	 * Method: throws
	 * 
	 * Specifies what exception will be thrown in this behavior.
	 */
	protected function throws($exception)
	{
		$this->specified = true;
		$this->expected_exception = $exception;
	}
	
	/*
	 * Method: triggers
	 * 
	 * Specifies what error message should be returned by this behavior
	 */
	protected function triggers($error)
	{
		$this->specified = true;
		$this->expected_error = strip_tags($error);
	}

	/*
	 * Method: triggersError
	 * 
	 * An alias for triggers().
	 */
	protected function triggersError($error)
	{
		$this->triggers($error);
	}

	/*
	 * Method: throwsException
	 * 
	 * An alias for throws().
	 */
	protected function throwsException($exception)
	{
		$this->throws($exception);
	}
}

// ==================
// = Helper classes =
// ==================

/*
 * Class: AeroSpecVariable
 * 
 * General assertion class.
 */
class AeroSpecVariable
{
	protected $subject;
	protected $type = 'variable';
	
	/*
	 * Constructor: __construct
	 */
	public function __construct(&$variable, $reference = true)
	{
		$this->subject =& $variable;
		
		if (!$reference)
		{
			$this->type = 'value';
		}
	}
	
	// ===================
	// = Group: Equality =
	// ===================
	
	/*
	 * Method: is
	 */
	public function is($type)
	{
		return $this->_is($type,true);
	}
	
	/*
	 * Method: isNot
	 */
	public function isNot($type)
	{
		return $this->_is($type,false);
	}
	
	/*
	 * Method: _is
	 */
	protected function _is($type, $true = true)
	{
		if (is_bool($type))
		{
			$type = $type ? 'true' : 'false';
		}
		else if (is_null($type))
		{
			$type = 'null';
		}
		
		switch ($type)
		{
			case 'null':
				$type = 'null';
				$result = is_null($this->subject);
				break;
			case 'bool':
			case 'boolean':
				$type = 'a boolean';
				$result = is_bool($this->subject);
				break;
			case 'false':
				$type = 'false';
				$result = $this->subject === false;
				break;
			case 'true':
				$type = 'true';
				$result = $this->subject === true;
				break;
			case 'object':
				$type = 'an object';
				$result = is_object($this->subject);
				break;
			case 'string':
				$type = 'a string';
				$result = is_string($this->subject);
				break;
			case 'int':
			case 'integer':
				$type = 'an integer';
				$result = is_int($this->subject);
				break;
			case 'float':
				$type = 'a float';
				$result = is_float($this->subject);
				break;
			case 'numeric':
				$type = 'a numeric';
				$result = is_numeric($this->subject);
				break;
			case 'array':
				$type = 'an array';
				$result = is_array($this->subject);
				break;
			default:
				throw new AeroSpecFailed('Cannot check if <var>'.$this->type.'</var> is '.preg_replace('~(\w+)$~', '<kbd>$1</kbd>', $type).'. Such type is not supported.');
		}
		
		if ($result !== $true)
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> is '.($true ? 'not ' : '').preg_replace('~(\w+)$~', '<kbd>$1</kbd>', $type).'.', $this->subject, null, 2);
		}
		
		return $this;
	}
	
	/*
	 * Method: equals
	 */
	public function equals($variable)
	{
		return $this->_equals($variable, true);
	}
	
	/*
	 * Method: isEqualTo
	 */
	public function isEqualTo($variable)
	{
		return $this->_equals($variable, true);
	}

	/*
	 * Method: doesNotEqual
	 */
	public function doesNotEqual($variable)
	{
		return $this->_equals($variable, false);
	}

	/*
	 * Method: isNotEqualTo
	 */
	public function isNotEqualTo($variable)
	{
		return $this->_equals($variable, false);
	}
	
	/*
	 * Method: _equals
	 */
	protected function _equals($variable, $true = true)
	{
		if ($true == ($this->subject != $variable))
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> is '.($true ? 'not ' : '').'equal to the target.', $this->subject, $variable,3);
		}
		
		return $this;
	}
	
	// ======================
	// = Group: Indenticity =
	// ======================
	
	/*
	 * Method: isIdenticalTo
	 */
	public function isIdenticalTo($variable)
	{
		return $this->_isIdenticalTo($variable,true);
	}
	
	/*
	 * Method: isNotIdenticalTo
	 */
	public function isNotIdenticalTo($variable)
	{
		return $this->_isIdenticalTo($variable,false);
	}
	
	/*
	 * Method: _isIdenticalTo
	 */
	protected function _isIdenticalTo($variable, $true = true)
	{
		if ($true == ($this->subject !== $variable))
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> is '.($true ? 'not ' : '').'identical to the target.', $this->subject, $variable, 3);
		}
		
		return $this;
	}
	
	// ====================
	// = Group: Reference =
	// ====================
	
	/*
	 * Method: references
	 */
	public function references(&$variable)
	{
		return $this->_references($variable,true);
	}
	
	/*
	 * Method: doesNotReference
	 */
	public function doesNotReference(&$variable)
	{
		return $this->_references($variable,false);
	}
	
	/*
	 * Method: _references
	 */
	protected function _references(&$variable, $true = true)
	{
		$message = 'This <var>'.$this->type.'</var> does '.($true ? 'not ' : '').'reference the target.';
		
		if (is_object($this->subject))
		{
			if (($this->subject !== $variable) == $true)
			{
				throw new AeroSpecFailed($message,$this->subject,$variable,3);
			}
		}
		else
		{
			$temp = $variable;
			$variable = uniqid("test");
			$is_ref = ($this->subject === $variable);
			$variable = $temp;
			
			if (!$is_ref == $true)
			{
				throw new AeroSpecFailed($message,$this->subject,$variable,3);
			}
		}
		
		return $this;
	}
}

/*
 * Class: AeroSpecObject
 */
class AeroSpecObject extends AeroSpecVariable
{
	protected $type = 'object';
	
	// ===================
	// = Group: Ancestry =
	// ===================
	
	/*
	 * Method: isInstanceOf
	 */
	public function isInstanceOf($class_name)
	{
		return $this->_isInstanceOf($class_name, true);
	}

	/*
	 * Method: isNotInstanceOf
	 */
	public function isNotInstanceOf($class_name)
	{
		return $this->_isInstanceOf($class_name, false);
	}
	
	/*
	 * Method: _isInstanceOf
	 */
	protected function _isInstanceOf($class_name, $true = true)
	{
		if (($this->subject instanceof $class_name) != $true)
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> is '.($true ? 'not ' : '').' an instance of <var>'.$class_name.'</var>.',$this->subject, null, 2);
		}
		
		return $this;
	}
	
	// =========================
	// = Group: Implementation =
	// =========================
	
	/*
	 * Method: hasMethod
	 */
	public function hasMethod($name)
	{
		return $this->_hasMethod($name, true);
	}
	
	/*
	 * Method: doesNotHaveMethod
	 */
	public function doesNotHaveMethod($name)
	{
		return $this->_hasMethod($name, false);
	}
	
	/*
	 * Method: _hasMethod
	 */
	protected function _hasMethod($name,$true = true)
	{
		if (!method_exists($this->subject,$name) == $true)
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> does '.($true ? 'not ' : '').'have a method <var>'.$name.'()</var>.',$this->subject, null, 2);
		}
		
		return $this;
	}
	
	/*
	 * Method: hasProperty
	 */
	public function hasProperty($name)
	{
		return $this->_hasProperty($name,true);
	}
	
	/*
	 * Method: doesNotHaveProperty
	 */
	public function doesNotHaveProperty($name)
	{
		return $this->_hasProperty($name,false);
	}
	
	/*
	 * Method: _hasProperty
	 */
	protected function _hasProperty($name,$true = true)
	{
		if (!property_exists($this->subject,$name) == $true)
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> '.($true ? 'does not have' : 'has').' a property <var>'.$name.'</var>.',$this->subject, null, 2);
		}
		
		return $this;
	}
	
	/*
	 * Method: property
	 */
	public function property($name)
	{
		$this->hasProperty($name);
		
		return new AeroSpecVariable($this->subject->$name);
	}
}

/*
 * Class: AeroSpecArray
 * 
 * Abstract class that <AeroSpecVector> and <AeroSpecDictionary> extend.
 */
abstract class AeroSpecArray extends AeroSpecVariable
{
	protected $type = 'array';
}

/*
 * Class: AeroSpecVector
 * 
 * Used to make assertions about indexed arrays.
 */
class AeroSpecVector extends AeroSpecArray
{
	protected $type = 'vector';
	
	/*
	 * Method: valueAt
	 */
	public function valueAt($index)
	{
		$this->hasValueAt($index);
		
		return new AeroSpecVariable($this->subject[$index]);
	}

	/*
	 * Method: hasValueAt
	 */
	public function hasValueAt($index)
	{
		return $this->_hasValueAt($index,true);
	}

	/*
	 * Method: hasItemAt
	 */
	public function hasItemAt($index)
	{
		return $this->_hasValueAt($index,true);
	}

	/*
	 * Method: doesNotHaveValueAt
	 */
	public function doesNotHaveValueAt($index)
	{
		return $this->_hasValueAt($index,false);
	}

	/*
	 * Method: doesNotHaveItemAt
	 */
	public function doesNotHaveItemAt($index)
	{
		return $this->_hasValueAt($index,false);
	}
	
	/*
	 * Method: _hasValueAt
	 */
	protected function _hasValueAt($index, $true = true)
	{
		if (!isset($this->subject[$index]) == $true)
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> '.($true ? 'does not have' : 'has').' a value at index <var>'.$index.'</var>.',$this->subject, null, 2);
		}
		
		return $this;
	}
}

/*
 * Class: AeroSpecDictionary
 * 
 * Used to make assertions about associative arrays.
 */
class AeroSpecDictionary extends AeroSpecArray
{
	protected $type = 'dictionary';
	
	/*
	 * Method: valueOf
	 */
	public function valueOf($key)
	{
		$this->hasKey($key);
		
		return new AeroSpecVariable($this->subject[$key]);
	}
	
	/*
	 * Method: hasKey
	 */
	public function hasKey($key)
	{
		return $this->_hasKey($key,true);
	}
	
	/*
	 * Method: doesNotHaveKey
	 */
	public function doesNotHaveKey($key)
	{
		return $this->_hasKey($key,false);
	}
	
	/*
	 * Method: hasKey
	 */
	protected function _hasKey($key,$true = true)
	{
		if (!isset($this->subject[$key]) == $true)
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> '.($true ? 'does not have' : 'has').' <var>'.$key.'</var> key.',$this->subject, null, 2);
		}
		
		return $this;
	}
}


/*
 * Class: AeroSpecString
 */
class AeroSpecString extends AeroSpecVariable
{
	protected $type = 'string';
	
	/*
	 * Method: matches
	 */
	public function matches($regexp)
	{
		return $this->_matches($regexp,true);
	}
	
	/*
	 * Method: doesNotMatch
	 */
	public function doesNotMatch($regexp)
	{
		return $this->_matches($regexp,false);
	}

	/*
	 * Method: _matches
	 */
	protected function _matches($regexp, $true = true)
	{
		if ($true == (preg_match($regexp, $this->subject) === 0))
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> '.($true ? 'does not match' : 'matches').' regular expression <kbd>'.$regexp .'</kbd>.',$this->subject, null, 2);
		}
		
		return $this;
	}
	
	/*
	 * Method: contains
	 */
	public function contains($substring)
	{
		return $this->_contains($substring,true);
	}
	
	/*
	 * Method: doesNotContain
	 */
	public function doesNotContain($substring)
	{
		return $this->_contains($substring,false);
	}
	
	/*
	 * Method: _contains
	 */
	protected function _contains($substring, $true = true)
	{
		if ($true != (strpos($this->subject, $substring) !== false))
		{
			throw new AeroSpecFailed('This <var>'.$this->type.'</var> '.($true ? 'does not contain' : 'contains').' the target.',$this->subject, $substring, 3);
		}
		
		return $this;
	}
	
	/*
	 * Method: isSimilarTo
	 */
	function isSimilarTo($string)
	{
		return $this->_isSimilarTo($string,true);
	}
	
	/*
	 * Method: isNotSimilarTo
	 */
	function isNotSimilarTo($string)
	{
		return $this->_isSimilarTo($string,false);
	}
	
	/*
	 * Method: _isSimilarTo
	 */
	function _isSimilarTo($string, $true = true)
	{
		if (!is_string($string))
		{
			throw new AeroSpecFailed('Cannot check if these <var>'.$this->type.'s</var> are similar. Target is not a <var>string</var>.', $string, null, 2);
		}
		
		$text = preg_replace('~\s+~', ' ', array($string, $this->subject));
		
		if ($true == ($text[0] !== $text[1]))
		{
			throw new AeroSpecFailed('These <var>' . $this->type . 's</var> are '.($true ? 'not ' : '').'similar.', $this->subject, $string, 3);
		}
		
		return $this;
	}
}

/*
 * Class: AeroSpecHTML
 */
class AeroSpecHTML extends AeroSpecString
{
	protected $dom;
	
	/*
	 * Constructor: __construct
	 */
	function __construct($html_or_url, $load = false)
	{
		if (!$load)
		{
			$html = $html_or_url;
		}
		else
		{
			$url = parse_url($html_or_url);
			
			// Get remote resource using file_get_contents
			$html = file_get_contents(
				(isset($url['scheme']) ? $url['scheme'] : 'http') . '://' .
				(isset($url['user']) ? $url['user'] . ( 
					isset($url['pass']) ? (':' . $url['pass']) : ''
					. '@') : '') .
				(isset($url['host']) ? $url['host'] : $_SERVER['HTTP_HOST']) . 
				(isset($url['path']) ? $url['path'] . (
					isset($url['query']) ? ('?' . $url['query']) : '' 
					) : '')
			);
		}
		
		parent::__construct($html);
		
		$this->dom = Aero::load('libs/parsers/dom',array('html' => $html));
	}
	
	/*
	 * Method: hasElement
	 */
	public function hasElement($element)
	{
		return $this->_hasElement($element,true);
	}

	/*
	 * Method: doesNotHaveElement
	 */
	public function doesNotHaveElement($element)
	{
		return $this->_hasElement($element,false);
	}
	
	/*
	 * Method: _hasElement
	 */
	protected function _hasElement($element, $true = true)
	{
		if ($true != ($this->dom->getElementsByTagName($element)->length > 0))
		{
			throw new AeroSpecFailed('This <var>page</var> '.($true ? 'does not have ' : 'has ').'element <kbd>'.$element.'</kbd>.', $this->subject, null, 2);
		}
		
		return $this;
	}

	/*
	 * Method: hasElementWithId
	 */
	public function hasElementWithId($id)
	{
		return $this->_hasElementWithId($id,true);
	}

	/*
	 * Method: doesNotHaveElementWithId
	 */
	public function doesNotHaveElementWithId($id)
	{
		return $this->_hasElementWithId($id,false);
	}
	
	/*
	 * Method: _hasElementWithId
	 */
	protected function _hasElementWithId($id, $true = true)
	{
		if ($true == is_null($this->dom->getElementById($id)))
		{
			throw new AeroSpecFailed('This <var>page</var> '.($true ? 'does not have an' : 'has').' element with id <kbd>'.$id.'</kbd>.', $this->subject, null, 2);
		}
		
		return $this;
	}
	
	/*
	 * Method: matchesSelector
	 */
	public function matchesSelector($selector)
	{
		return $this->_matchesSelector($selector,true);
	}

	/*
	 * Method: doesNotMatchSelector
	 */
	public function doesNotMatchSelector($selector)
	{
		return $this->_matchesSelector($selector,false);
	}
    
	/*
	 * Method: _matchesSelector
	 */
	protected function _matchesSelector($selector, $true = true)
	{
		if ($true == ($this->dom->getElementsBySelector($selector)->length === 0))
		{
			throw new AeroSpecFailed('This <var>page</var> '.($true ? 'does not match ' : 'does match ').'this selector: <kbd>'.$selector.'</kbd>.', $this->subject, null, 2);
		}
		
		return $this;
	}
	
	/*
	 * Method: _isSimilarTo
	 */
	function _isSimilarTo($string, $true = true)
	{
		if (!is_string($string))
		{
			throw new AeroSpecFailed('Cannot check if these <var>'.$this->type.'s</var> are similar. Target is not a <var>string</var>.', $string, null, 2);
		}
		
		$blockLvl = '(p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|li|address|'.
					'script|noscript|form|fieldset|iframe|math)';
		
		$text = preg_replace(array('~[\s]+~', '~^ | $~m', '~('.$blockLvl.'>) +| +(</?'.$blockLvl.')~m'), 
			array(' ', '', '\1\3'),
			array($string, $this->subject));
		
		if ($true == ($text[0] !== $text[1]))
		{
			throw new AeroSpecFailed('These <var>' . $this->type . 's</var> are '.($true ? 'not ' : '').'similar.', $this->subject, $string, 3);
		}
		
		return $this;
	}
}

// ==============
// = Exceptions =
// ==============

// Difference Engine
define('DIFF_DELETED', 1);
define('DIFF_ADDED', 2);
define('DIFF_UNCHANGED', 4);
define('DIFF_END', 8);
define('DIFF_ALTERED', 16);
define('DIFF_NEW_LINE', '&lt;-- new line at the end --&gt;');

/*
 * Class: AeroSpecException
 */
class AeroSpecException extends Exception
{
	protected $failure = null;
	protected $failure_scope = null;
	protected $subject;
	protected $target;
	protected $subject_header;
	protected $target_header;
	protected $has_subject = false;
	protected $has_target = false;
	
	/*
	 * Constructor: __construct
	 */
	public function __construct($message = "", $subject = null, $target = null, $params_count = 1, $hsubject = 'Subject', $htarget = 'Target')
	{
		parent::__construct($message);
		
		if ($params_count > 1)
		{
			$this->has_subject = true;
			$this->has_target = $params_count > 2 ? true : false;
		}
		
		$this->target = $target;
		$this->subject = $subject;
		$this->target_type = gettype($target);
		$this->subject_type = gettype($subject);
		$this->target_header = $htarget;
		$this->subject_header = $hsubject;
		
		if ($this->subject_type === 'string' && $this->target_type === 'string')
		{
			$this->markDifferences();
		}
		else
		{
			$this->subject = !is_scalar($this->subject) ? $this->dump($this->subject) : htmlentities($this->subject, ENT_NOQUOTES, 'UTF-8');
			$this->target = !is_scalar($this->target) ? $this->dump($this->target) : htmlentities($this->target, ENT_NOQUOTES, 'UTF-8');
		}
		
		$trace = $this->getTrace();
		
		foreach ($trace as $point)
		{
			if (!isset($point['class']) || !is_subclass_of($point['class'],'AeroSpec'))
			{
				$this->failure = $point;
				continue;
			}
			
			$this->failure_scope = $point['function'];
			
			break;
		}
	}
	
	/*
	 * Method: failedCode
	 * 
	 * Returns an indexed array of the lines of code where the error has happened.
	 */
	public function failedCode()
	{
		if (is_null($this->failure_scope))
		{
			return array();
		}
		
		if (isset($this->failure['file']))
		{
			$inspector = Aero::load('libs/parsers/php',$this->failure['file']);
		}
		
		if (!isset($inspector))
		{
			return '';
		}
		else if (!is_object($inspector))
		{
			$path = $this->failure['file'];
			$line = $this->failure['line'];
			
			$path_components = explode('/',$path);
			$path = '';
			
			do 
			{
				$path = array_pop($path_components) . ($path ? '/'. $path : '');
			}
			while (strlen($path) < 30);
			
			if (count($path_components) > 0)
			{
				$path = '&hellip;/'.$path;
			}
			
			return 'See <kbd>'. $path . '</kbd> at line <kbd>' . $line . '</kbd>';
		}
		
		// Determin how many lines to show in the output
		$behavior_begins_on = $inspector->findLineReverse(array(
				'keyword' => 'function',
				'code' => $this->failure_scope
			), 1, $this->failure['line']);
		$test_begins_on = $inspector->findLineReverse(array('code' => '$this'),1,$this->failure['line'] - 1);
		
		$start_line = max($behavior_begins_on,$test_begins_on);
		
		return $inspector->getLines($start_line, $this->failure['line']);
	}
	
	/*
	 * Method: fileLink
	 */
	public function fileLink($line = null)
	{
		if (is_null($line))
		{
			$line = $this->failure['line'];
		}
		
		$file = $this->failure['file'];
		
		return str_replace(array(':file:',':line:'), array($file, $line), Aero::option('aero.file_link_template', 'txmt://open/?url=file://:file:&line=:line:'));
	}
	
	/*
	 * Method: hasSubject
	 */
	public function hasSubject()
	{
		return $this->has_subject;
	}

	/*
	 * Method: hasTarget
	 */
	public function hasTarget()
	{
		return $this->has_target;
	}
	
	/*
	 * Method: subjectHeader
	 */
	public function subjectHeader()
	{
		return $this->subject_header;
	}
	
	/*
	 * Method: targetHeader
	 */
	public function targetHeader()
	{
		return $this->target_header;
	}
	
	/*
	 * Method: subjectType
	 */
	public function subjectType()
	{
		return $this->subject_type;
	}
	
	/*
	 * Method: targetType
	 */
	public function targetType()
	{
		return $this->target_type;
	}
	
	/*
	 * Method: subject
	 */
	public function subject()
	{
		return $this->subject;
	}
	
	/*
	 * Method: target
	 */
	public function target()
	{
		return $this->target;
	}
	
	/*
	 * Method: dump
	 */
	protected function dump(&$object)
	{
		Aero::import('libs/parsers/php');
		
		return $dump = var_export($object,true);
		
		$code = new AeroCodeInspector($dump,false);
		
		return implode("\n",$code->getLines());
	}
	
	/*
	 * Method: markDifferences
	 * 
	 * Marks differences per line differences between the subject and target.
	 * 
	 */
	public function markDifferences()
	{
		$diff = Aero::load('libs/diff');
		$t1 = htmlentities($this->subject,ENT_NOQUOTES,'UTF-8');
		$t2 = htmlentities($this->target,ENT_NOQUOTES,'UTF-8');
		list($changeset_1,$changeset_2) = $diff->diff($t1, $t2);
		
		$this->subject = $diff->display($changeset_1);
		$this->target = $diff->display($changeset_2);
	}
}

/*
 * Class: AeroSpecSkipped
 */
class AeroSpecSkipped extends AeroSpecException {}

/*
 * Class: AeroSpecFailed
 */
class AeroSpecFailed extends AeroSpecException {}
